feat(fsdp): meta-init + rank0-streamed weight loading (--fsdp-load-mode stream)#39
Draft
zhihengy wants to merge 11 commits into
Draft
feat(fsdp): meta-init + rank0-streamed weight loading (--fsdp-load-mode stream)#39zhihengy wants to merge 11 commits into
zhihengy wants to merge 11 commits into
Conversation
…de stream)
Every rank used to materialize the full pipeline on CPU
(DiffusionPipeline.from_pretrained), move the whole unsharded model to GPU,
and only then fully_shard — peak GPU = full model (54 GB for one Wan2.2-A14B
expert in fp32 master dtype), N×full disk reads, and no way to honor a meta
init context (diffusers' pipeline loader crashes under one).
New default path (--fsdp-load-mode stream), per component:
build on meta via init_empty_weights(include_buffers=False) # buffers real
-> fully_shard on meta (costs nothing)
-> to_empty: allocate only this rank's shards; carry non-persistent
buffers (Wan rope tables) across the transition
-> rank0 streams safetensors file-by-file, set_model_state_dict
broadcast_from_rank0 scatters into the shards
-> LoRA adapters build on meta too (low_cpu_mem_usage) and initialize
after materialization; data-aware inits (pissa/olora) are rejected
-> family postprocess hook runs the before-fsdp hook after
materialization, where qwen-image's rope parity rebuild has real
CUDA tensors instead of silently no-oping on meta
--fsdp-load-mode legacy keeps the old path for custom pipelines.
Wan2.2-A14B (both experts, fp32, 2×H200): init 285s -> 71s, peak GPU
80.5 -> 54.0 GB (= the sharded params themselves); only rank0 reads the
checkpoint. Streamed weights verified bitwise-equal to from_pretrained
(params + buffers) at 1.3B scale and in the new 2-GPU CI test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rank0 keeps its real shards through .to(cuda) instead of to_empty + re-fill; every buffer is broadcast from rank0 after set_model_state_dict (covers non-persistent buffers absent from any state_dict, instead of trusting recomputed-from-config values); under fsdp_cpu_offload the load happens on GPU and buffers stay there, since CPUOffloadPolicy manages params only. Add short specs to the three loading helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the loading interface (load_component/load_scheduler) enforced at class-definition time rather than first call; the conditionally-required attention hooks stay NotImplementedError as before. Test-local subclasses stub the abstract loaders themselves (also drops the stale load_models_and_scheduler stubs left from the old interface). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit 671b9b41; the CI test is still wanted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blings WanPipeline declares transformer/transformer_2 optional with a None default, which drops them from expected_modules: the None passed to DiffusionPipeline.from_pretrained is silently ignored and both 14B experts load from disk on every rank -- during load_scheduler too -- and on non-rank0 (low_cpu_mem_usage=False) trip diffusers' _keep_in_fp32_modules guard, crashing any multi-rank init. Resolve the component class from model_index.json and from_pretrained(subfolder=...) it directly instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Benchmark: meta-init + rank0-broadcast loading vs main (8x H200, cold page cache)
Measured on the real
FSDPTrainRayActor.initmodel-loading path;checkpoint page cache evicted (
posix_fadvise DONTNEED) before every run;latency and peak memory are the max across ranks.
Per-rank shard sha256 digests are bitwise-identical between the two
modes for all four families (same world size, same sharding plan, so the
local shards must match exactly).
Init latency (s) — lower is better
xychart-beta title "init latency (s): main (back) vs stream (front)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "seconds" 0 --> 434 bar [37.2, 247.6, 377.0, 68.2] bar [15.8, 120.2, 133.2, 56.3]Peak GPU memory per rank (GB) — lower is better
xychart-beta title "peak GPU/rank (GB): main (back) vs stream (front)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "GB" 0 --> 88 bar [9.3, 76.3, 60.1, 35.5] bar [2.9, 10.9, 14.6, 5.2]xychart-beta title "init speedup (x, higher is better)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "speedup (x)" 0 --> 3 bar [2.4, 2.1, 2.8, 1.2]xychart-beta title "peak GPU/rank reduction (%)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "% reduction" 0 --> 99 bar [68.8, 85.7, 75.7, 85.4]LoRA load equivalence (same harness,
--lora, rank 64, gaussian init)Both branches wrap each component with PEFT before FSDP: main inits the
adapter on every rank (identical seed), this PR inits it on rank0 only and
broadcasts A/B together with the base weights (non-rank0 builds the adapter
on meta via
get_peft_model(..., low_cpu_mem_usage=True)). Checks per run:no meta residue,
lora_B == 0,lora_Afinite and nonzero, buffersreplicated across ranks; then per-rank digests are compared across branches.
Base weights are bitwise-identical to main under the PEFT-wrapped FQNs for
all four families.
lora_Aeven matches bitwise wherever both branchesconsume the RNG in the same order; where it differs it is still the same
N(0, 1/r)init from the same peft code, andlora_B == 0makes theinitial model function identical to the base model either way.
Seed determinism: the whole LoRA suite was executed twice with the same
seed; run1 and run2 full digests (base + random-init
lora_A) arebitwise-identical on every rank, for every family, on both branches.
Loading mechanics:
low_cpu_mem_usage, meta init, and_keep_in_fp32_modulesThis is the most confusing corner of the PR, so spelling it out.
diffusers'
from_pretrainedhas two loading paths:low_cpu_mem_usage=True(diffusers' default): self-contained fast path.diffusers builds the module on its own internal meta device, then reads the
checkpoint and materializes every param directly by assignment. It always
produces real weights and bypasses any ambient init context (assignment does
not go through parameter registration, so an external
init_empty_weightscannot intercept it). Peak CPU ~= 1x model size.
low_cpu_mem_usage=False: plain path. Runs the model's ordinary__init__(params allocated through normal registration), then reads thefull state dict and
copy_()s it into the params. In normal use this peaksat ~2x model size -- that is exactly what
Truewas invented to avoid.Why each rank uses what it uses:
low_cpu_mem_usageTrueinit_empty_weightsFalse(forced)__init__allocation is hijacked -> params stay on meta (0 bytes); the state-dict read still happens (transient CPU) butcopy_()into meta tensors is a no-op and the dict is freed. Net result: zero-memory shellsNon-rank0 must use
False: it is the only path whose construction step canbe intercepted by our ambient meta context. With
True, diffusers would handback fully materialized weights on every rank and the meta-shell design (shard
on meta ->
to_emptyonly this rank's shard on GPU -> broadcast from rank0)would silently degenerate into N full copies; the actor guards this with an
explicit "did not honor meta initialization" check.
The conflict: some model classes (e.g.
WanTransformer3DModel) declare_keep_in_fp32_modules-- submodules that must stay fp32 even when the restloads in bf16. diffusers implements that pinning only inside the
Truepath, and rather than silently dropping the pin it raises up-front:
ValueError: low_cpu_mem_usage cannot be False when keep_in_fp32_modules is True. So non-rank0 (forcedFalse) cannot load such a class as-is.How we handle it: on non-rank0 only,
_keep_in_fp32_modulesis temporarilycleared on the class for the duration of the
from_pretrainedcall. This issafe because non-rank0 params are meta placeholders anyway -- authoritative
dtypes come from rank0 (which loads with
Trueand honors the pin), andsync_model_dtypesbroadcasts rank0's per-tensor dtypes to every rank beforesharding, so all shards agree.
The Wan crash this benchmark surfaced (fixed in this PR)
Components used to be loaded through
DiffusionPipeline.from_pretrainedwithevery non-target component passed as
None. That None-skip convention onlyworks for components that are required in the pipeline
__init__signature:only those land in
passed_class_obj, the map whoseNoneentries suppressloading.
WanPipelinedeclarestransformer/transformer_2as optionalwith a
Nonedefault, so_get_signature_keysdrops them fromexpected_modules, theNonewe passed was silently discarded, and both 14Bexperts loaded from disk on every rank -- during
load_schedulertoo. Onnon-rank0 that load ran with
low_cpu_mem_usage=Falsewhile the fp32-guardpatch had been applied to the scheduler's class, so the unpatched
WanTransformer3DModelhit the ValueError above and every multi-rank initcrashed. Single-GPU runs never crashed (rank0 loads with
True); the wastewas silent.
Fix: resolve the component class from
model_index.jsonand load it directlyvia
cls.from_pretrained(ckpt, subfolder=component). Only the named componentis ever touched, so there is nothing to skip, and the fp32 patch provably
targets the class actually being loaded.
🤖 Generated with Claude Code